diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs index 64d4fd112..7b533ee7f 100644 --- a/BTCPayServer.Tests/PlaywrightTester.cs +++ b/BTCPayServer.Tests/PlaywrightTester.cs @@ -474,6 +474,7 @@ namespace BTCPayServer.Tests goto retry; } } + await Server.ExplorerNode.GenerateAsync(1); await Page.ReloadAsync(); await Page.Locator("#CancelWizard").ClickAsync(); return addressStr; @@ -541,5 +542,23 @@ namespace BTCPayServer.Tests await page.BringToFrontAsync(); return new SwitchDisposable(page, old, this, closeAfter); } + + public async Task GoToWalletSend(WalletId walletId = null) + { + await GoToWallet(walletId, navPages: WalletsNavPages.Send); + return new(Page); + } + + public class SendWalletPMO(IPage page) + { + public Task FillAddress(BitcoinAddress address) => page.FillAsync("[name='Outputs[0].DestinationAddress']", + address.ToString()); + + public Task SweepBalance() => page.ClickAsync("#SweepBalance"); + + public Task Sign() => page.ClickAsync("#SignTransaction"); + + public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture)); + } } } diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs index 40fab4ed0..521d13104 100644 --- a/BTCPayServer.Tests/WalletTests.cs +++ b/BTCPayServer.Tests/WalletTests.cs @@ -2,8 +2,6 @@ using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Models; -using BTCPayServer.Client; -using BTCPayServer.Client.Models; using BTCPayServer.Views.Wallets; using NBitcoin; using Xunit; @@ -87,6 +85,21 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper) // However, the new transaction should have copied the CPFP tag from the transaction it replaced, and have a RBF label as well. await AssertHasLabels(s, rbfTx, "CPFP"); await AssertHasLabels(s, rbfTx, "RBF"); + + // Now, we sweep all the UTXOs to a single destination. This should be RBF-able. (Fee deducted on the lone UTXO) + await s.GoToWallet(navPages: WalletsNavPages.Send); + var send = await s.GoToWalletSend(); + await send.FillAddress(new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest)); + await send.SweepBalance(); + await send.SetFeeRate(20m); + await send.Sign(); + await s.Page.ClickAsync("button[value=broadcast]"); + // Now we RBF the sweep + await ClickBumpFee(s); + Assert.Equal("RBF", await s.Page.Locator("#BumpMethod").InnerTextAsync()); + await s.ClickPagePrimary(); + await s.Page.ClickAsync("#BroadcastTransaction"); + await AssertHasLabels(s, "RBF"); } private async Task CreateInvoices(PlaywrightTester tester) @@ -107,13 +120,10 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper) await s.Page.ReloadAsync(); await s.Page.Locator($"{TxRowSelector(txId)} .transaction-label[data-value=\"{label}\"]").WaitForAsync(); } - private async Task AssertHasLabels(PlaywrightTester s, string label) - { - await s.Page.ReloadAsync(); - await s.Page.Locator($".transaction-label[data-value=\"{label}\"]").First.WaitForAsync(); - } - static string TxRowSelector(uint256 txId) => $".transaction-row[data-value=\"{txId}\"]"; + private Task AssertHasLabels(PlaywrightTester s, string label) => AssertHasLabels(s, null, label); + + static string TxRowSelector(uint256 txId) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]"; private async Task SelectTransactions(PlaywrightTester s, params uint256[] txs) { @@ -123,7 +133,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper) } } - private async Task ClickBumpFee(PlaywrightTester s, uint256 txId) + private async Task ClickBumpFee(PlaywrightTester s, uint256 txId = null) { await s.Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn"); } diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 95411e248..900f72885 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -284,7 +284,6 @@ namespace BTCPayServer.Controllers model.RecommendedSatoshiPerByte = recommendedFees.Where(option => option != null).ToList(); model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; - if (HttpContext.Request.Method != HttpMethods.Post) { model.Command = null; @@ -303,7 +302,7 @@ namespace BTCPayServer.Controllers var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!; if (model.BumpMethod == "CPFP") { - var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation); + var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation, cancellationToken); List bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint)); if (bumpableUTXOs.Count == 0) @@ -350,12 +349,16 @@ namespace BTCPayServer.Controllers // RBF is only supported for a single tx var tx = txs[bumpTarget.GetSingleTransactionId()!]; var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change); + if (changeOutput is null && + tx is { Transaction: { Outputs: [{ ScriptPubKey: {} singleAddress }] }}) + changeOutput = new() { ScriptPubKey = singleAddress, Index = 0 }; if (tx.Inputs.Count != tx.Transaction?.Inputs.Count || changeOutput is null) { this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]); return View(nameof(WalletBumpFee), model); } + IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing) { if (missing is not null) @@ -365,7 +368,14 @@ namespace BTCPayServer.Controllers return View(nameof(WalletBumpFee), vm); } - var bumpResult = bumpable[tx.TransactionId].ReplacementInfo!.CalculateBumpResult(targetFeeRate); + var bumpableTx = bumpable[tx.TransactionId].ReplacementInfo!; + if (targetFeeRate < bumpableTx.CalculateNewMinFeeRate()) + { + ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The selected fee rate is too small. The minimum is {0} sat/byte", bumpableTx.CalculateNewMinFeeRate().SatoshiPerByte]); + return View(nameof(WalletBumpFee), model); + } + + var bumpResult = bumpableTx.CalculateBumpResult(targetFeeRate); var createPSBT = new CreatePSBTRequest() { RBF = true, @@ -378,7 +388,12 @@ namespace BTCPayServer.Controllers { ExplicitFee = bumpResult.NewTxFee }, - ExplicitChangeAddress = changeOutput.Address, + ExplicitChangeAddress = changeOutput switch + { + { Address: {} addr } => PSBTDestination.Create(addr), + { ScriptPubKey: {} scriptPubKey } => PSBTDestination.Create(scriptPubKey), + _ => throw new InvalidOperationException("Invalid change output") + }, Destinations = tx.Transaction.Outputs.AsIndexedOutputs() .Select(o => new CreatePSBTDestination() { @@ -904,8 +919,7 @@ namespace BTCPayServer.Controllers { try { - var result = await feeProvider.GetFeeRateAsync( - (int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); + var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); options.Add(new WalletSendModel.FeeRateOption() { Target = time, diff --git a/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs b/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs index 1d4bab847..c727e19bc 100644 --- a/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs +++ b/BTCPayServer/HostedServices/TransactionLabelMarkerHostedService.cs @@ -92,24 +92,20 @@ namespace BTCPayServer.HostedServices { if (transactionEvent.NewTransactionEvent.Replacing is not null) { - foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing) { var replacedwoId = new WalletObjectId(wid, WalletObjectData.Types.Tx, replaced.ToString()); var replacedo = await _walletRepository.GetWalletObject(replacedwoId); var replacedLinks = replacedo?.GetLinks().Where(t => t.type != WalletObjectData.Types.Tx) ?? []; - if (replacedLinks.Count() != 0) + var rbf = new WalletObjectId(wid, WalletObjectData.Types.RBF, ""); + var label = WalletRepository.CreateLabel(rbf); + newObjs.Add(label.ObjectData); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id)); + links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, rbf, new JObject() { - var rbf = new WalletObjectId(wid, WalletObjectData.Types.RBF, ""); - var label = WalletRepository.CreateLabel(rbf); - newObjs.Add(label.ObjectData); - links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id)); - links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, rbf, new JObject() - { - ["txs"] = JArray.FromObject(new[] { replaced.ToString() }) - })); - } + ["txs"] = JArray.FromObject(new[] { replaced.ToString() }) + })); foreach (var link in replacedLinks) { links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata)); diff --git a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs index 70adeeca2..d87245317 100644 --- a/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs +++ b/BTCPayServer/Payments/Bitcoin/NBXplorerListener.cs @@ -155,10 +155,7 @@ namespace BTCPayServer.Payments.Bitcoin if (evt.DerivationStrategy != null) { wallet.InvalidateCache(evt.DerivationStrategy); - var validOutputs = network.GetValidOutputs(evt).ToList(); - if (!validOutputs.Any()) - break; - foreach (var output in validOutputs) + foreach (var output in network.GetValidOutputs(evt)) { if (!output.matchedOutput.Value.IsCompatible(network)) continue; diff --git a/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs b/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs index 6ed2f9edb..2299ca59b 100644 --- a/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs +++ b/BTCPayServer/Services/Fees/NBxplorerFeeProvider.cs @@ -10,7 +10,7 @@ namespace BTCPayServer.Services.Fees { public async Task GetFeeRateAsync(int blockTarget = 20) { - return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate; + return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate; } } } diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 981cd874f..53f0460ed 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -338,9 +338,9 @@ namespace BTCPayServer.Services.Wallets WHERE code = @code AND wallet_id=@walletId AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL GROUP BY code, tx_id ) - SELECT tt.tx_id, u.raw, tt.input_count, tt.change_count, uu.unspent_count FROM unconfs u + SELECT tt.tx_id, u.raw, tt.input_count, tt.change_count, COALESCE(uu.unspent_count, 0) FROM unconfs u JOIN tracked_txs tt USING (code, tx_id) - JOIN unspent_utxos uu USING (code, tx_id); + LEFT JOIN unspent_utxos uu USING (code, tx_id); """, parameters: new { @@ -367,8 +367,11 @@ namespace BTCPayServer.Services.Wallets { continue; } - if ((state.MempoolInfo?.FullRBF is true || tx.RBF) && tx.Inputs.Count == r.input_count && - r.change_count > 0) + + var rbf = state.MempoolInfo?.FullRBF is true || Network.IsBTC || tx.RBF; + rbf &= tx.Inputs.Count == r.input_count; + rbf &= r.change_count > 0 || tx.Outputs.Count == 1; + if (rbf) { canRBF.Add(uint256.Parse(r.tx_id)); } diff --git a/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml b/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml index 9fdeed9cf..f1385b4fb 100644 --- a/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletBumpFee.cshtml @@ -67,18 +67,10 @@ } } - @if (!ViewContext.ModelState.IsValid) - { -
    - @foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid)) - { - foreach (var error in errors.Value.Errors) - { -
  • @error.ErrorMessage
  • - } - } -
- } + @if (!ViewContext.ModelState.IsValid) + { +
+ } @if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId) {
diff --git a/BTCPayServer/Views/UIWallets/WalletSend.cshtml b/BTCPayServer/Views/UIWallets/WalletSend.cshtml index 77571610b..922c4c222 100644 --- a/BTCPayServer/Views/UIWallets/WalletSend.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletSend.cshtml @@ -127,7 +127,7 @@
Your available balance is - @Model.CryptoCode. + @Model.CryptoCode. @if (Model.ImmatureBalance > 0) {
@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]
@@ -205,7 +205,7 @@
- + @if (!string.IsNullOrEmpty(Model.PayJoinBIP21)) {