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;
}
}
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<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.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");
}

View File

@@ -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<OutPoint> 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,

View File

@@ -92,15 +92,12 @@ 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);
@@ -109,7 +106,6 @@ namespace BTCPayServer.HostedServices
{
["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));

View File

@@ -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;

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
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));
}

View File

@@ -69,15 +69,7 @@
}
@if (!ViewContext.ModelState.IsValid)
{
<ul class="text-danger">
@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>
<div asp-validation-summary="All"></div>
}
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
{

View File

@@ -127,7 +127,7 @@
</div>
<div class="form-text crypto-info">
<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)
{
<span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span>