Add ability to RBF a transaction with a single output

This commit is contained in:
nicolas.dorier
2025-05-22 22:34:05 +09:00
parent b5a1de75c9
commit b45a575ee4
9 changed files with 80 additions and 49 deletions

View File

@@ -474,6 +474,7 @@ namespace BTCPayServer.Tests
goto retry; goto retry;
} }
} }
await Server.ExplorerNode.GenerateAsync(1);
await Page.ReloadAsync(); await Page.ReloadAsync();
await Page.Locator("#CancelWizard").ClickAsync(); await Page.Locator("#CancelWizard").ClickAsync();
return addressStr; return addressStr;
@@ -541,5 +542,23 @@ namespace BTCPayServer.Tests
await page.BringToFrontAsync(); await page.BringToFrontAsync();
return new SwitchDisposable(page, old, this, closeAfter); return new SwitchDisposable(page, old, this, closeAfter);
} }
public async Task<SendWalletPMO> 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));
}
} }
} }

View File

@@ -2,8 +2,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Views.Wallets; using BTCPayServer.Views.Wallets;
using NBitcoin; using NBitcoin;
using Xunit; 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. // 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, "CPFP");
await AssertHasLabels(s, rbfTx, "RBF"); 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) private async Task CreateInvoices(PlaywrightTester tester)
@@ -107,13 +120,10 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ReloadAsync(); await s.Page.ReloadAsync();
await s.Page.Locator($"{TxRowSelector(txId)} .transaction-label[data-value=\"{label}\"]").WaitForAsync(); 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) 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"); await s.Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn");
} }

View File

@@ -284,7 +284,6 @@ namespace BTCPayServer.Controllers
model.RecommendedSatoshiPerByte = model.RecommendedSatoshiPerByte =
recommendedFees.Where(option => option != null).ToList(); recommendedFees.Where(option => option != null).ToList();
model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
if (HttpContext.Request.Method != HttpMethods.Post) if (HttpContext.Request.Method != HttpMethods.Post)
{ {
model.Command = null; 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 })!; var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!;
if (model.BumpMethod == "CPFP") if (model.BumpMethod == "CPFP")
{ {
var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation); var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation, cancellationToken);
List<OutPoint> bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint)); List<OutPoint> bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint));
if (bumpableUTXOs.Count == 0) if (bumpableUTXOs.Count == 0)
@@ -350,12 +349,16 @@ namespace BTCPayServer.Controllers
// RBF is only supported for a single tx // RBF is only supported for a single tx
var tx = txs[bumpTarget.GetSingleTransactionId()!]; var tx = txs[bumpTarget.GetSingleTransactionId()!];
var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change); 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 || if (tx.Inputs.Count != tx.Transaction?.Inputs.Count ||
changeOutput is null) changeOutput is null)
{ {
this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]); this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]);
return View(nameof(WalletBumpFee), model); return View(nameof(WalletBumpFee), model);
} }
IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing) IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing)
{ {
if (missing is not null) if (missing is not null)
@@ -365,7 +368,14 @@ namespace BTCPayServer.Controllers
return View(nameof(WalletBumpFee), vm); 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() var createPSBT = new CreatePSBTRequest()
{ {
RBF = true, RBF = true,
@@ -378,7 +388,12 @@ namespace BTCPayServer.Controllers
{ {
ExplicitFee = bumpResult.NewTxFee 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() Destinations = tx.Transaction.Outputs.AsIndexedOutputs()
.Select(o => new CreatePSBTDestination() .Select(o => new CreatePSBTDestination()
{ {
@@ -904,8 +919,7 @@ namespace BTCPayServer.Controllers
{ {
try try
{ {
var result = await feeProvider.GetFeeRateAsync( var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
(int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
options.Add(new WalletSendModel.FeeRateOption() options.Add(new WalletSendModel.FeeRateOption()
{ {
Target = time, Target = time,

View File

@@ -92,24 +92,20 @@ namespace BTCPayServer.HostedServices
{ {
if (transactionEvent.NewTransactionEvent.Replacing is not null) if (transactionEvent.NewTransactionEvent.Replacing is not null)
{ {
foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing) foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing)
{ {
var replacedwoId = new WalletObjectId(wid, var replacedwoId = new WalletObjectId(wid,
WalletObjectData.Types.Tx, replaced.ToString()); WalletObjectData.Types.Tx, replaced.ToString());
var replacedo = await _walletRepository.GetWalletObject(replacedwoId); var replacedo = await _walletRepository.GetWalletObject(replacedwoId);
var replacedLinks = replacedo?.GetLinks().Where(t => t.type != WalletObjectData.Types.Tx) ?? []; 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, ""); ["txs"] = JArray.FromObject(new[] { replaced.ToString() })
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() })
}));
}
foreach (var link in replacedLinks) foreach (var link in replacedLinks)
{ {
links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata)); links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata));

View File

@@ -155,10 +155,7 @@ namespace BTCPayServer.Payments.Bitcoin
if (evt.DerivationStrategy != null) if (evt.DerivationStrategy != null)
{ {
wallet.InvalidateCache(evt.DerivationStrategy); wallet.InvalidateCache(evt.DerivationStrategy);
var validOutputs = network.GetValidOutputs(evt).ToList(); foreach (var output in network.GetValidOutputs(evt))
if (!validOutputs.Any())
break;
foreach (var output in validOutputs)
{ {
if (!output.matchedOutput.Value.IsCompatible(network)) if (!output.matchedOutput.Value.IsCompatible(network))
continue; continue;

View File

@@ -10,7 +10,7 @@ namespace BTCPayServer.Services.Fees
{ {
public async Task<FeeRate> GetFeeRateAsync(int blockTarget = 20) public async Task<FeeRate> GetFeeRateAsync(int blockTarget = 20)
{ {
return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate; return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate;
} }
} }
} }

View File

@@ -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 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 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 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 parameters: new
{ {
@@ -367,8 +367,11 @@ namespace BTCPayServer.Services.Wallets
{ {
continue; 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)); canRBF.Add(uint256.Parse(r.tx_id));
} }

View File

@@ -67,18 +67,10 @@
<input type="hidden" asp-for="Outpoints[i]" /> <input type="hidden" asp-for="Outpoints[i]" />
} }
} }
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<ul class="text-danger"> <div asp-validation-summary="All"></div>
@foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid)) }
{
foreach (var error in errors.Value.Errors)
{
<li>@error.ErrorMessage</li>
}
}
</ul>
}
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId) @if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
{ {
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">

View File

@@ -127,7 +127,7 @@
</div> </div>
<div class="form-text crypto-info"> <div class="form-text crypto-info">
<span text-translate="true">Your available balance is</span> <span text-translate="true">Your available balance is</span>
<button type="button" class="crypto-balance-link btn btn-link p-0 align-baseline">@Model.CurrentBalance</button> <span>@Model.CryptoCode</span>. <button id="SweepBalance" type="button" class="crypto-balance-link btn btn-link p-0 align-baseline">@Model.CurrentBalance</button> <span>@Model.CryptoCode</span>.
@if (Model.ImmatureBalance > 0) @if (Model.ImmatureBalance > 0)
{ {
<span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span> <span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span>
@@ -205,7 +205,7 @@
<input asp-for="AlwaysIncludeNonWitnessUTXO" class="btcpay-toggle me-3"/> <input asp-for="AlwaysIncludeNonWitnessUTXO" class="btcpay-toggle me-3"/>
<label asp-for="AlwaysIncludeNonWitnessUTXO" class="form-check-label"></label> <label asp-for="AlwaysIncludeNonWitnessUTXO" class="form-check-label"></label>
</div> </div>
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21)) @if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
{ {
<div class="d-flex mb-3"> <div class="d-flex mb-3">