[UX/UI] Add CPFP (#3395)

* Add CPFP

* Sign PSBT should go back to the initial page
This commit is contained in:
Nicolas Dorier 2022-02-10 12:24:28 +09:00 committed by GitHub
parent efed00f58b
commit 300d84c5d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 432 additions and 204 deletions

View file

@ -127,7 +127,6 @@ namespace BTCPayServer
public string BlockExplorerLinkDefault { get; set; }
public string DisplayName { get; set; }
public int Divisibility { get; set; } = 8;
[Obsolete("Should not be needed")]
public bool IsBTC
{
get

View file

@ -204,8 +204,7 @@ namespace BTCPayServer.Tests
{
// Local link, this is fine
}
else if (attributeValue.StartsWith("http://") || attributeValue.StartsWith("https://") ||
attributeValue.StartsWith("@"))
else if (attributeValue.StartsWith("http://") || attributeValue.StartsWith("https://"))
{
// This can be an external link. Treating it as such.
var rel = GetAttributeValue(node, "rel");

View file

@ -140,7 +140,7 @@ namespace BTCPayServer.Tests
var postRedirectView = Assert.IsType<ViewResult>(view);
var postRedirectViewModel = Assert.IsType<PostRedirectViewModel>(postRedirectView.Model);
Assert.Equal(actionName, postRedirectViewModel.AspAction);
var redirectedPSBT = postRedirectViewModel.Parameters.Single(p => p.Key == "psbt" || p.Key == "SigningContext.PSBT").Value;
var redirectedPSBT = postRedirectViewModel.FormParameters.Single(p => p.Key == "psbt" || p.Key == "SigningContext.PSBT").Value?.FirstOrDefault();
return redirectedPSBT;
}
}

View file

@ -85,6 +85,11 @@ namespace BTCPayServer.Tests
Driver.AssertNoError();
}
public void PayInvoice()
{
Driver.FindElement(By.Id("FakePayment")).Click();
}
/// <summary>
/// Use this ServerUri when trying to browse with selenium
/// Because for some reason, the selenium container can't resolve the tests container domain name
@ -151,6 +156,7 @@ namespace BTCPayServer.Tests
}
Driver.WaitForElement(By.Id("StoreSelectorCreate")).Click();
var name = "Store" + RandomUtils.GetUInt64();
TestLogs.LogInformation($"Created store {name}");
Driver.WaitForElement(By.Id("Name")).SendKeys(name);
Driver.WaitForElement(By.Id("Create")).Click();
Driver.FindElement(By.Id("StoreNav-StoreSettings")).Click();
@ -161,7 +167,7 @@ namespace BTCPayServer.Tests
return (name, storeId);
}
public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit)
public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit)
{
var isImport = !string.IsNullOrEmpty(seed);
GoToWalletSettings(cryptoCode);
@ -181,11 +187,11 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.Id("ImportWalletOptionsLink")).Click();
Driver.FindElement(By.Id("ImportSeedLink")).Click();
Driver.FindElement(By.Id("ExistingMnemonic")).SendKeys(seed);
Driver.SetCheckbox(By.Id("SavePrivateKeys"), privkeys);
Driver.SetCheckbox(By.Id("SavePrivateKeys"), isHotWallet);
}
else
{
var option = privkeys ? "Hotwallet" : "Watchonly";
var option = isHotWallet ? "Hotwallet" : "Watchonly";
TestLogs.LogInformation($"Generating new seed ({option})");
Driver.FindElement(By.Id("GenerateWalletLink")).Click();
Driver.FindElement(By.Id($"Generate{option}Link")).Click();
@ -195,7 +201,8 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.CssSelector($"#ScriptPubKeyType option[value={format}]")).Click();
Driver.ToggleCollapse("AdvancedSettings");
Driver.SetCheckbox(By.Id("ImportKeysToRPC"), importkeys);
if (importkeys is bool v)
Driver.SetCheckbox(By.Id("ImportKeysToRPC"), v);
Driver.FindElement(By.Id("Continue")).Click();
if (isImport)
@ -366,7 +373,10 @@ namespace BTCPayServer.Tests
public void GoToStore(string storeId, StoreNavPages storeNavPage = StoreNavPages.General)
{
if (storeId is not null)
{
GoToUrl($"/stores/{storeId}/");
StoreId = storeId;
}
Driver.FindElement(By.Id("StoreNav-StoreSettings")).Click();
@ -412,8 +422,9 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.Id($"StoreSelectorMenuItem-{storeId}")).Click();
}
public void GoToInvoiceCheckout(string invoiceId)
public void GoToInvoiceCheckout(string? invoiceId = null)
{
invoiceId ??= InvoiceId;
Driver.FindElement(By.Id("StoreNav-Invoices")).Click();
Driver.FindElement(By.Id($"invoice-checkout-{invoiceId}")).Click();
CheckForJSErrors();
@ -433,6 +444,7 @@ namespace BTCPayServer.Tests
else
{
GoToUrl(storeId == null ? "/invoices/" : $"/stores/{storeId}/invoices/");
StoreId = storeId;
}
}
@ -473,6 +485,7 @@ namespace BTCPayServer.Tests
)
{
GoToInvoices(storeId);
Driver.FindElement(By.Id("CreateNewInvoice")).Click();
if (amount is decimal v)
Driver.FindElement(By.Id("Amount")).SendKeys(v.ToString(CultureInfo.InvariantCulture));
@ -487,8 +500,12 @@ namespace BTCPayServer.Tests
Driver.FindElement(By.Id("Create")).Click();
var statusElement = FindAlertMessage(expectedSeverity);
return expectedSeverity == StatusMessageModel.StatusSeverity.Success ? statusElement.Text.Split(" ")[1] : null;
var inv = expectedSeverity == StatusMessageModel.StatusSeverity.Success ? statusElement.Text.Split(" ")[1] : null;
InvoiceId = inv;
TestLogs.LogInformation($"Created invoice {inv}");
return inv;
}
string InvoiceId;
public async Task FundStoreWallet(WalletId walletId = null, int coins = 1, decimal denomination = 1m)
{
@ -503,22 +520,6 @@ namespace BTCPayServer.Tests
}
}
public void PayInvoice(WalletId walletId, string invoiceId)
{
GoToInvoiceCheckout(invoiceId);
var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
GoToWallet(walletId);
Driver.FindElement(By.Id("bip21parse")).Click();
Driver.SwitchTo().Alert().SendKeys(bip21);
Driver.SwitchTo().Alert().Accept();
Driver.FindElement(By.Id("SignTransaction")).Click();
Driver.FindElement(By.Id("SignWithSeed")).Click();
Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
}
private void CheckForJSErrors()
{
//wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste

View file

@ -64,6 +64,48 @@ namespace BTCPayServer.Tests
Assert.Contains("Starting listening NBXplorer", s.Driver.PageSource);
s.Driver.Quit();
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseCPFP()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
s.CreateNewStore();
s.GenerateWallet(isHotWallet: true);
await s.FundStoreWallet();
for (int i = 0; i < 3; i++)
{
s.CreateInvoice();
s.GoToInvoiceCheckout();
s.PayInvoice();
s.GoToInvoices(s.StoreId);
}
// Let's CPFP from the invoices page
s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true);
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
s.Driver.FindElement(By.Id("BumpFee")).Click();
s.Driver.FindElement(By.Id("BroadcastTransaction")).Click();
s.FindAlertMessage();
Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url);
// CPFP again should fail because all invoices got bumped
s.GoToInvoices();
s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true);
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
s.Driver.FindElement(By.Id("BumpFee")).Click();
var err = s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error);
Assert.Contains("any UTXO available", err.Text);
Assert.Contains($"/stores/{s.StoreId}/invoices", s.Driver.Url);
// But we should be able to bump from the wallet's page
s.GoToWallet(navPages: WalletsNavPages.Transactions);
s.Driver.SetCheckbox(By.Id("selectAllCheckbox"), true);
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
s.Driver.FindElement(By.Id("BumpFee")).Click();
s.Driver.FindElement(By.Id("BroadcastTransaction")).Click();
s.FindAlertMessage();
Assert.Contains($"/wallets/{s.WalletId}", s.Driver.Url);
}
[Fact(Timeout = TestTimeout)]
[Trait("Lightning", "Lightning")]
@ -932,7 +974,7 @@ namespace BTCPayServer.Tests
{
var cryptoCode = "BTC";
s.CreateNewStore();
s.GenerateWallet(cryptoCode, "melody lizard phrase voice unique car opinion merge degree evil swift cargo", privkeys: isHotwallet);
s.GenerateWallet(cryptoCode, "melody lizard phrase voice unique car opinion merge degree evil swift cargo", isHotWallet: isHotwallet);
s.GoToWalletSettings(cryptoCode);
if (isHotwallet)
Assert.Contains("View seed", s.Driver.PageSource);

View file

@ -15,6 +15,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
@ -29,6 +30,7 @@ using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitpayClient;
using NBXplorer;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
@ -441,17 +443,61 @@ namespace BTCPayServer.Controllers
await _InvoiceRepository.MassArchive(selectedItems);
TempData[WellKnownTempData.SuccessMessage] = $"{selectedItems.Length} invoice{(selectedItems.Length == 1 ? "" : "s")} archived.";
break;
case "unarchive":
await _InvoiceRepository.MassArchive(selectedItems, false);
TempData[WellKnownTempData.SuccessMessage] = $"{selectedItems.Length} invoice{(selectedItems.Length == 1 ? "" : "s")} unarchived.";
break;
case "cpfp":
if (selectedItems.Length == 0)
return NotSupported("No invoice has been selected");
var network = _NetworkProvider.BTC;
var explorer = _ExplorerClients.GetExplorerClient(_NetworkProvider.BTC);
IActionResult NotSupported(string err)
{
TempData[WellKnownTempData.ErrorMessage] = err;
return RedirectToAction(nameof(ListInvoices), new { storeId });
}
if (explorer is null)
return NotSupported("This feature is only available to BTC wallets");
if (this.GetCurrentStore().Role != StoreRoles.Owner)
return Forbid();
var settings = (this.GetCurrentStore().GetDerivationSchemeSettings(_NetworkProvider, network.CryptoCode));
var derivationScheme = settings.AccountDerivation;
if (derivationScheme is null)
return NotSupported("This feature is only available to BTC wallets");
var bumpableAddresses = (await GetAddresses(selectedItems))
.Where(p => p.GetPaymentMethodId().IsBTCOnChain)
.Select(p => p.GetAddress()).ToHashSet();
var utxos = await explorer.GetUTXOsAsync(derivationScheme);
var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && bumpableAddresses.Contains(u.ScriptPubKey.Hash.ToString())).ToArray();
var parameters = new MultiValueDictionary<string, string>();
foreach (var utxo in bumpableUTXOs)
{
parameters.Add($"outpoints[]", utxo.Outpoint.ToString());
}
return View("PostRedirect", new PostRedirectViewModel
{
AspController = "UIWallets",
AspAction = nameof(UIWalletsController.WalletCPFP),
RouteParameters = {
{ "walletId", new WalletId(storeId, network.CryptoCode).ToString() },
{ "returnUrl", Url.Action(nameof(ListInvoices), new { storeId }) }
},
FormParameters = parameters,
});
}
}
return RedirectToAction(nameof(ListInvoices), new { storeId });
}
private async Task<AddressInvoiceData[]> GetAddresses(string[] selectedItems)
{
using var ctx = _dbContextFactory.CreateContext();
return await ctx.AddressInvoices.Where(i => selectedItems.Contains(i.InvoiceDataId)).ToArrayAsync();
}
[HttpGet("i/{invoiceId}")]
[HttpGet("i/{invoiceId}/{paymentMethodId}")]
[HttpGet("invoice")]

View file

@ -42,6 +42,8 @@ namespace BTCPayServer.Controllers
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _paymentHostedService;
private readonly LanguageService _languageService;
private readonly ExplorerClientProvider _ExplorerClients;
private readonly UIWalletsController _walletsController;
public WebhookSender WebhookNotificationManager { get; }
@ -58,7 +60,9 @@ namespace BTCPayServer.Controllers
ApplicationDbContextFactory dbContextFactory,
PullPaymentHostedService paymentHostedService,
WebhookSender webhookNotificationManager,
LanguageService languageService)
LanguageService languageService,
ExplorerClientProvider explorerClients,
UIWalletsController walletsController)
{
_CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
@ -72,6 +76,8 @@ namespace BTCPayServer.Controllers
_paymentHostedService = paymentHostedService;
WebhookNotificationManager = webhookNotificationManager;
_languageService = languageService;
this._ExplorerClients = explorerClients;
_walletsController = walletsController;
}

View file

@ -312,18 +312,16 @@ namespace BTCPayServer.Controllers
var redirectVm = new PostRedirectViewModel()
{
FormUrl = viewModel.RedirectUrl.AbsoluteUri,
Parameters =
FormParameters =
{
new KeyValuePair<string, string>("apiKey", key.Id),
new KeyValuePair<string, string>("userId", key.UserId)
}
{ "apiKey", key.Id },
{ "userId", key.UserId },
},
};
foreach (var permission in permissions)
{
redirectVm.Parameters.Add(
new KeyValuePair<string, string>("permissions[]", permission));
redirectVm.FormParameters.Add("permissions[]", permission);
}
return View("PostRedirect", redirectVm);
}

View file

@ -70,6 +70,115 @@ namespace BTCPayServer.Controllers
return psbt;
}
[HttpPost("{walletId}/cpfp")]
public async Task<IActionResult> WalletCPFP([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string[] outpoints, string[] transactionHashes, string returnUrl)
{
outpoints ??= Array.Empty<string>();
transactionHashes ??= Array.Empty<string>();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(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 settings = (this.GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, network.CryptoCode));
var derivationScheme = settings.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 Redirect(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);
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(UIWalletsController.WalletSign),
RouteParameters = {
{ "walletId", walletId.ToString() },
{ "returnUrl", returnUrl }
},
FormParameters =
{
{ "walletId", walletId.ToString() },
{ "psbt", psbt.ToHex() }
}
});
}
[HttpPost("{walletId}/sign")]
public async Task<IActionResult> WalletSign([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTViewModel vm, string returnUrl = null, string command = null)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
vm.SigningContext.PSBT ??= psbt.ToBase64();
if (returnUrl is null)
returnUrl = Url.Action(nameof(WalletTransactions), new { walletId });
switch (command)
{
case "vault":
return ViewVault(walletId, vm.SigningContext);
case "seed":
return SignWithSeed(walletId, vm.SigningContext);
default:
break;
}
if (await CanUseHotWallet())
{
var derivationScheme = GetDerivationSchemeSettings(walletId);
if (derivationScheme.IsHotWallet)
{
var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
WellknownMetadataKeys.MasterHDKey);
if (extKey != null)
{
return SignWithSeed(walletId,
new SignWithSeedViewModel { SeedOrKey = extKey, SigningContext = vm.SigningContext });
}
}
}
return View("WalletSigningOptions", new WalletSigningOptionsModel(vm.SigningContext, returnUrl));
}
[HttpGet("{walletId}/psbt")]
public async Task<IActionResult> WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletPSBTViewModel vm)
@ -118,14 +227,12 @@ namespace BTCPayServer.Controllers
return View(vm);
}
vm.PSBT = psbt.ToBase64();
vm.PSBTHex = psbt.ToHex();
var res = await TryHandleSigningCommands(walletId, psbt, command, vm.SigningContext, nameof(WalletPSBT));
if (res != null)
{
return res;
}
switch (command)
{
case "sign":
return await WalletSign(walletId, vm, nameof(WalletPSBT));
case "decode":
ModelState.Remove(nameof(vm.PSBT));
ModelState.Remove(nameof(vm.FileName));
@ -407,6 +514,12 @@ namespace BTCPayServer.Controllers
vm.GlobalError = $"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)
{
@ -418,7 +531,12 @@ namespace BTCPayServer.Controllers
{
TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash()})";
}
return RedirectToWalletTransaction(walletId, transaction);
var returnUrl = this.HttpContext.Request.Query["returnUrl"].FirstOrDefault();
if (returnUrl is not null)
{
return Redirect(returnUrl);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
case "analyze-psbt":
return RedirectToWalletPSBT(new WalletPSBTViewModel()
@ -460,45 +578,5 @@ namespace BTCPayServer.Controllers
PSBT = sourcePSBT.ToBase64()
});
}
private async Task<IActionResult> TryHandleSigningCommands(WalletId walletId, PSBT psbt, string command,
SigningContextModel signingContext, string actionBack)
{
signingContext.PSBT = psbt.ToBase64();
switch (command)
{
case "sign":
var routeBack = new Dictionary<string, string>
{
{"action", actionBack }, {"walletId", walletId.ToString()}
};
return View("WalletSigningOptions", new WalletSigningOptionsModel(signingContext, routeBack));
case "vault":
return ViewVault(walletId, signingContext);
case "seed":
return SignWithSeed(walletId, signingContext);
case "nbx-seed":
if (await CanUseHotWallet())
{
var derivationScheme = GetDerivationSchemeSettings(walletId);
if (derivationScheme.IsHotWallet)
{
var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
.GetMetadataAsync<string>(derivationScheme.AccountDerivation,
WellknownMetadataKeys.MasterHDKey);
return SignWithSeed(walletId,
new SignWithSeedViewModel { SeedOrKey = extKey, SigningContext = signingContext });
}
}
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "NBX seed functionality is not available"
});
break;
}
return null;
}
}
}

View file

@ -723,17 +723,16 @@ namespace BTCPayServer.Controllers
{
PayJoinBIP21 = vm.PayJoinBIP21,
EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR,
ChangeAddress = psbtResponse.ChangeAddress?.ToString()
ChangeAddress = psbtResponse.ChangeAddress?.ToString(),
PSBT = psbt.ToHex()
};
var res = await TryHandleSigningCommands(walletId, psbt, command, signingContext, nameof(WalletSend));
if (res != null)
{
return res;
}
switch (command)
{
case "sign":
return await WalletSign(walletId, new WalletPSBTViewModel()
{
SigningContext = signingContext
});
case "analyze-psbt":
var name =
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
@ -823,10 +822,11 @@ namespace BTCPayServer.Controllers
{
AspController = "UIWallets",
AspAction = nameof(WalletPSBTReady),
Parameters =
RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
FormParameters =
{
new KeyValuePair<string, string>("SigningKey", vm.SigningKey),
new KeyValuePair<string, string>("SigningKeyPath", vm.SigningKeyPath)
{ "SigningKey", vm.SigningKey },
{ "SigningKeyPath", vm.SigningKeyPath }
}
};
AddSigningContext(redirectVm, vm.SigningContext);
@ -834,7 +834,11 @@ namespace BTCPayServer.Controllers
!string.IsNullOrEmpty(vm.SigningContext.PSBT))
{
//if a hw device signed a payjoin, we want it broadcast instantly
redirectVm.Parameters.Add(new KeyValuePair<string, string>("command", "broadcast"));
redirectVm.FormParameters.Add("command", "broadcast");
}
if (this.HttpContext.Request.Query["returnUrl"].FirstOrDefault() is string returnUrl)
{
redirectVm.RouteParameters.Add("returnUrl", returnUrl);
}
return View("PostRedirect", redirectVm);
}
@ -843,11 +847,11 @@ namespace BTCPayServer.Controllers
{
if (signingContext is null)
return;
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PSBT", signingContext.PSBT));
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.OriginalPSBT", signingContext.OriginalPSBT));
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21));
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)));
redirectVm.Parameters.Add(new KeyValuePair<string, string>("SigningContext.ChangeAddress", signingContext.ChangeAddress));
redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT);
redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT);
redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21);
redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
}
private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
@ -856,10 +860,11 @@ namespace BTCPayServer.Controllers
{
AspController = "UIWallets",
AspAction = nameof(WalletPSBT),
Parameters =
RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
FormParameters =
{
new KeyValuePair<string, string>("psbt", vm.PSBT),
new KeyValuePair<string, string>("fileName", vm.FileName)
{ "psbt", vm.PSBT },
{ "fileName", vm.FileName }
}
};
return View("PostRedirect", redirectVm);
@ -956,18 +961,6 @@ namespace BTCPayServer.Controllers
return v.ToString() + " " + network.CryptoCode;
}
private IActionResult RedirectToWalletTransaction(WalletId walletId, Transaction transaction)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
if (transaction != null)
{
var wallet = _walletProvider.GetWallet(network);
var derivationSettings = GetDerivationSchemeSettings(walletId);
wallet.InvalidateCache(derivationSettings.AccountDerivation);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
[HttpGet("{walletId}/rescan")]
public async Task<IActionResult> WalletRescan(
[ModelBinder(typeof(WalletIdModelBinder))]
@ -1067,6 +1060,7 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> WalletActions(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string command,
string[] selectedTransactions,
CancellationToken cancellationToken = default)
{
var derivationScheme = GetDerivationSchemeSettings(walletId);
@ -1075,6 +1069,31 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "cpfp":
{
selectedTransactions ??= Array.Empty<string>();
if (selectedTransactions.Length == 0)
{
TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected";
return RedirectToAction(nameof(WalletTransactions), new { walletId });
}
var parameters = new MultiValueDictionary<string, string>();
parameters.Add("walletId", walletId.ToString());
int i = 0;
foreach (var tx in selectedTransactions)
{
parameters.Add($"transactionHashes[{i}]", tx);
i++;
}
parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId }));
return View("PostRedirect", new PostRedirectViewModel
{
AspController = "UIWallets",
AspAction = nameof(UIWalletsController.WalletCPFP),
RouteParameters = { { "walletId", walletId.ToString() } },
FormParameters = parameters
});
}
case "prune":
{
var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken);

View file

@ -20,7 +20,7 @@ namespace BTCPayServer.Data
addressInvoiceData.Address = address + "#" + paymentMethodId.ToString();
return addressInvoiceData;
}
public static PaymentMethodId GetpaymentMethodId(this AddressInvoiceData addressInvoiceData)
public static PaymentMethodId GetPaymentMethodId(this AddressInvoiceData addressInvoiceData)
{
if (addressInvoiceData.Address == null)
return null;

View file

@ -498,14 +498,14 @@ namespace BTCPayServer
{
AspController = "UIHome",
AspAction = "RecoverySeedBackup",
Parameters =
FormParameters =
{
new KeyValuePair<string, string>("cryptoCode", vm.CryptoCode),
new KeyValuePair<string, string>("mnemonic", vm.Mnemonic),
new KeyValuePair<string, string>("passphrase", vm.Passphrase),
new KeyValuePair<string, string>("isStored", vm.IsStored ? "true" : "false"),
new KeyValuePair<string, string>("requireConfirm", vm.RequireConfirm ? "true" : "false"),
new KeyValuePair<string, string>("returnUrl", vm.ReturnUrl)
{ "cryptoCode", vm.CryptoCode },
{ "mnemonic", vm.Mnemonic },
{ "passphrase", vm.Passphrase },
{ "isStored", vm.IsStored ? "true" : "false" },
{ "requireConfirm", vm.RequireConfirm ? "true" : "false" },
{ "returnUrl", vm.ReturnUrl }
}
};
return controller.View("PostRedirect", redirectVm);

View file

@ -13,6 +13,5 @@ namespace BTCPayServer
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
return paymentMethod;
}
}
}

View file

@ -8,6 +8,7 @@ namespace BTCPayServer.Models
public string AspController { get; set; }
public string FormUrl { get; set; }
public List<KeyValuePair<string, string>> Parameters { get; set; } = new List<KeyValuePair<string, string>>();
public MultiValueDictionary<string, string> FormParameters { get; set; } = new MultiValueDictionary<string, string>();
public Dictionary<string, string> RouteParameters { get; set; } = new Dictionary<string, string>();
}
}

View file

@ -6,13 +6,13 @@ namespace BTCPayServer.Models.WalletViewModels
{
public WalletSigningOptionsModel(
SigningContextModel signingContext,
IDictionary<string, string> routeDataBack)
string returnUrl)
{
SigningContext = signingContext;
RouteDataBack = routeDataBack;
ReturnUrl = returnUrl;
}
public SigningContextModel SigningContext { get; }
public IDictionary<string, string> RouteDataBack { get; }
public string ReturnUrl { get; }
}
}

View file

@ -25,7 +25,6 @@ namespace BTCPayServer.Payments
CryptoCode = cryptoCode.ToUpperInvariant();
}
[Obsolete("Should only be used for legacy stuff")]
public bool IsBTCOnChain
{
get

View file

@ -585,7 +585,7 @@ namespace BTCPayServer.Services.Invoices
}
if (invoice.AddressInvoices != null)
{
entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetpaymentMethodId().ToString()).ToHashSet();
entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.GetAddress() + a.GetPaymentMethodId().ToString()).ToHashSet();
}
if (invoice.Events != null)
{

View file

@ -1,46 +1,71 @@
@model PostRedirectViewModel
@{
Layout = null;
var routeData = Context.GetRouteData();
var routeParams = new Dictionary<string, string>();
if (routeData != null)
{
routeParams["walletId"] = routeData.Values["walletId"]?.ToString();
}
var action = Model.FormUrl ?? Url.Action(Model.AspAction, Model.AspController, routeParams);
@model PostRedirectViewModel
@{
Layout = null;
}
<html lang="en">
<head>
<partial name="LayoutHead" />
<title>Post Redirect</title>
<partial name="LayoutHead" />
<title>Post Redirect</title>
</head>
<body>
<form method="post" id="postform" action="@action" rel="noreferrer noopener">
@Html.AntiForgeryToken()
@foreach (var o in Model.Parameters)
{
<input type="hidden" name="@o.Key" value="@o.Value"/>
}
<noscript>
<div class="modal-dialog modal-dialog-centered min-vh-100">
<div class="modal-content">
<div class="modal-body text-center my-3">
<p>
This redirection page is supposed to be submitted automatically.
<br>
Since you have not enabled JavaScript, please submit manually.
</p>
<button class="btn btn-primary" type="submit">Submit</button>
</div>
</div>
</div>
</noscript>
</form>
<script type="text/javascript">
document.forms.item(0).submit();
</script>
<partial name="LayoutFoot" />
@if (Model.FormUrl is null)
{
<form method="post" id="postform" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
@Html.AntiForgeryToken()
@foreach (var o in Model.FormParameters)
{
foreach (var v in o.Value)
{
<input type="hidden" name="@o.Key" value="@v" />
}
}
<noscript>
<div class="modal-dialog modal-dialog-centered min-vh-100">
<div class="modal-content">
<div class="modal-body text-center my-3">
<p>
This redirection page is supposed to be submitted automatically.
<br>
Since you have not enabled JavaScript, please submit manually.
</p>
<button class="btn btn-primary" type="submit">Submit</button>
</div>
</div>
</div>
</noscript>
</form>
}
else
{
<form method="post" id="postform" action="@Model.FormUrl" rel="noreferrer noopener">
@Html.AntiForgeryToken()
@foreach (var o in Model.FormParameters)
{
foreach (var v in o.Value)
{
<input type="hidden" name="@o.Key" value="@v" />
}
}
<noscript>
<div class="modal-dialog modal-dialog-centered min-vh-100">
<div class="modal-content">
<div class="modal-body text-center my-3">
<p>
This redirection page is supposed to be submitted automatically.
<br>
Since you have not enabled JavaScript, please submit manually.
</p>
<button class="btn btn-primary" type="submit">Submit</button>
</div>
</div>
</div>
</noscript>
</form>
}
<script type="text/javascript">
document.forms.item(0).submit();
</script>
<partial name="LayoutFoot" />
</body>
</html>

View file

@ -1,4 +1,4 @@
@model PaymentModel
@model PaymentModel
<div id="testing">
<hr class="my-3" />
@ -13,7 +13,7 @@
<div id="test-payment-crypto-code" class="input-group-addon">@Model.CryptoCode</div>
</div>
</div>
<button class="btn btn-primary" type="submit">{{$t("Fake Payment")}}</button>
<button id="FakePayment" class="btn btn-primary" type="submit">{{$t("Fake Payment")}}</button>
<p class="text-muted mt-1">{{$t("This is the same as running bitcoin-cli.sh sendtoaddress xxx")}}</p>
</form>
<form id="expire-invoice" action="/i/@Model.InvoiceId/expire" method="post" class="mb-1">

View file

@ -310,11 +310,12 @@
Actions
</button>
<div class="dropdown-menu" aria-labelledby="ActionsDropdownToggle">
<button type="submit" asp-action="MassAction" class="dropdown-item" name="command" value="archive" id="ActionsDropdownArchive"><i class="fa fa-archive"></i> Archive</button>
<button type="submit" class="dropdown-item" name="command" value="archive" id="ActionsDropdownArchive"><i class="fa fa-archive"></i> Archive</button>
@if (Model.IncludeArchived)
{
<button type="submit" asp-action="MassAction" class="dropdown-item" name="command" value="unarchive" id="ActionsDropdownUnarchive"><i class="fa fa-archive"></i> Unarchive</button>
}
<button id="BumpFee" type="submit" class="dropdown-item" name="command" value="cpfp">Bump fee</button>
</div>
</span>
<span>

View file

@ -3,22 +3,19 @@
var walletId = Context.GetRouteValue("walletId").ToString();
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, "Sign PSBT", walletId);
var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault();
}
@section Navbar {
<a asp-action="WalletPSBT" asp-route-walletId="@walletId" id="GoBack">
@if (returnUrl is string)
{
<a href="@returnUrl" id="GoBack">
<vc:icon symbol="back" />
</a>
<a asp-action="WalletSend" asp-route-walletId="@walletId" class="cancel">
<a href="@returnUrl" class="cancel">
<vc:icon symbol="close" />
</a>
}
@section PageFootContent
{
<script>
delegate('click', '#GoBack', () => { history.back(); return false; })
</script>
}
}
<header class="text-center">
@ -40,7 +37,7 @@
<div asp-validation-summary="All" class="text-danger"></div>
<form method="post" asp-action="SignWithSeed" asp-route-walletId="@walletId">
<form method="post" asp-action="SignWithSeed" asp-route-walletId="@walletId" asp-route-returnUrl="@returnUrl">
<partial name="SigningContext" for="SigningContext"/>
<div class="form-group">
<label asp-for="SeedOrKey" class="form-label"></label>

View file

@ -1,12 +1,13 @@
@model WalletPSBTViewModel
@model WalletPSBTViewModel
@addTagHelper *, BundlerMinifier.TagHelpers
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var isReady = !Model.HasErrors;
var isSignable = !isReady && Model.NBXSeedAvailable;
var needsExport = !isSignable && !isReady;
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? "Confirm broadcasting this transaction" : "Transaction Details", walletId);
var walletId = Context.GetRouteValue("walletId").ToString();
var isReady = !Model.HasErrors;
var isSignable = !isReady && Model.NBXSeedAvailable;
var needsExport = !isSignable && !isReady;
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? "Confirm broadcasting this transaction" : "Transaction Details", walletId);
var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault();
}
@section PageHeadContent {
@ -37,9 +38,12 @@
}
@section Navbar {
<a asp-action="WalletSend" asp-route-walletId="@Context.GetRouteValue("walletId")" class="cancel">
@if (returnUrl is string)
{
<a href="@returnUrl" class="cancel">
<vc:icon symbol="close" />
</a>
}
}
<header class="text-center mb-3">
@ -56,13 +60,13 @@
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="FileName"/>
<div class="d-flex flex-column flex-sm-row flex-wrap justify-content-center align-items-sm-center">
<button type="submit" id="SignTransaction" name="command" value="nbx-seed" class="btn btn-primary">Sign transaction</button>
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary">Sign transaction</button>
</div>
</form>
}
else if (isReady)
{
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@walletId" class="my-5">
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@walletId" asp-route-returnUrl="@returnUrl" class="my-5">
<input type="hidden" asp-for="SigningKey" />
<input type="hidden" asp-for="SigningKeyPath" />
<partial name="SigningContext" for="SigningContext" />
@ -75,7 +79,7 @@ else if (isReady)
}
else
{
<button type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast transaction</button>
<button id="BroadcastTransaction" type="submit" class="btn btn-primary" name="command" value="broadcast">Broadcast transaction</button>
}
</div>
</form>

View file

@ -230,7 +230,7 @@
</div>
</div>
<div class="form-group d-flex mt-2">
<button type="submit" id="SignTransaction" name="command" value="@(Model.NBXSeedAvailable ? "nbx-seed" : "sign")" class="btn btn-primary">Sign transaction</button>
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary">Sign transaction</button>
<button type="button" id="bip21parse" class="ms-3 btn btn-secondary" title="Paste BIP21/Address"><i class="fa fa-paste"></i></button>
<button type="button" id="scanqrcode" class="ms-3 btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan BIP21/Address with camera"><i class="fa fa-camera"></i></button>
</div>

View file

@ -3,15 +3,19 @@
var walletId = Context.GetRouteValue("walletId").ToString();
Layout = "_LayoutWizard";
ViewData.SetActivePage(WalletsNavPages.Send, "Sign the transaction", walletId);
var returnUrl = this.Context.Request.Query["returnUrl"].FirstOrDefault();
}
@section Navbar {
<a asp-action="WalletPSBT" asp-route-walletId="@walletId" id="GoBack">
@if (returnUrl is string)
{
<a href="@returnUrl" id="GoBack">
<vc:icon symbol="back" />
</a>
<a asp-action="WalletSend" asp-route-walletId="@walletId" class="cancel">
<a href="@returnUrl" class="cancel">
<vc:icon symbol="close" />
</a>
}
}
<header class="text-center">
@ -27,7 +31,7 @@
</div>
<div id="body" class="my-4">
<form id="broadcastForm" asp-action="WalletSendVault" asp-route-walletId="@walletId" method="post" style="display:none;">
<form id="broadcastForm" asp-action="WalletSendVault" asp-route-walletId="@walletId" asp-route-returnUrl="@returnUrl" method="post" style="display:none;">
<input type="hidden" id="WalletId" asp-for="WalletId" />
<input type="hidden" asp-for="WebsocketPath" />
<partial name="SigningContext" for="SigningContext" />
@ -44,8 +48,6 @@
<script src="~/js/vaultbridge.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script src="~/js/vaultbridge.ui.js" type="text/javascript" defer="defer" asp-append-version="true"></script>
<script>
delegate('click', '#GoBack', () => { history.back(); return false; })
async function askSign() {
var websocketPath = $("#WebsocketPath").val();
var loc = window.location, ws_uri;

View file

@ -8,10 +8,10 @@
}
@section Navbar {
<a asp-all-route-data="Model.RouteDataBack">
<a href="@Model.ReturnUrl">
<vc:icon symbol="back" />
</a>
<a asp-all-route-data="Model.RouteDataBack" class="cancel">
<a href="@Model.ReturnUrl" class="cancel">
<vc:icon symbol="close" />
</a>
}
@ -21,7 +21,7 @@
<p class="lead text-secondary mt-3">You can sign the transaction using one of the following methods.</p>
</header>
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId">
<form method="post" asp-action="WalletSign" asp-route-walletId="@walletId" asp-route-returnUrl="@Model.ReturnUrl">
<partial name="SigningContext" for="SigningContext" />
@if (BTCPayNetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode).VaultSupported)

View file

@ -1,4 +1,4 @@
@model ListTransactionsViewModel
@model ListTransactionsViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
Layout = "../Shared/_NavLayout.cshtml";
@ -67,18 +67,24 @@
@* Custom Range Modal *@
<script>
delegate('click', '#switchTimeFormat', switchTimeFormat)
delegate('click', '#switchTimeFormat', switchTimeFormat);
delegate('click', '#selectAllCheckbox', e => {
document.querySelectorAll(".selector").forEach(checkbox => {
checkbox.checked = e.target.checked;
});
});
</script>
}
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<form method="post" asp-action="WalletActions" asp-route-walletId="@Context.GetRouteValue("walletId")">
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@Context.GetRouteValue("walletId")">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle mb-1" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Actions
</button>
<div class="dropdown-menu" aria-labelledby="ActionsDropdownToggle">
<button id="BumpFee" name="command" type="submit" class="dropdown-item" value="cpfp">Bump fee (CPFP)</button>
<a asp-action="WalletRescan" asp-route-walletId="@Context.GetRouteValue("walletId")" class="dropdown-item">Rescan wallet for missing transactions</a>
<button name="command" type="submit" class="dropdown-item" value="prune">Prune old transactions from history</button>
@if (User.IsInRole(Roles.ServerAdmin))
@ -116,6 +122,9 @@
<table class="table table-hover">
<thead class="thead-inverse">
<tr>
<th style="width:2rem;" class="only-for-js">
<input id="selectAllCheckbox" type="checkbox" class="form-check-input" />
</th>
<th style="min-width: 90px;" class="col-md-auto">
Date
<a id="switchTimeFormat" href="#">
@ -132,6 +141,9 @@
@foreach (var transaction in Model.Transactions)
{
<tr>
<td class="only-for-js">
<input name="selectedTransactions" type="checkbox" class="selector form-check-input" form="WalletActions" value="@transaction.Id" />
</td>
<td>
<span class="switchTimeFormat" data-switch="@transaction.Timestamp.ToTimeAgo()">
@transaction.Timestamp.ToBrowserDate()